今天前半講認證與權限本身要決定什麼,後半講這一段交給 AI 做的時候,會遇到什麼、怎麼處理。
昨天處理掉 migration 的問題,今天要來處理系統的認證與權限。
這兩個字常被混著講,但它們問的問題不同:
| 問什麼 | 這個系統的答案 | |
|---|---|---|
| 認證(Authentication) | 你是誰 | 以前:你說了算。現在:伺服器從簽章解出來 |
| 授權(Authorization) | 你可以做什麼 | 你是不是這份記錄的作者、在不在這個版本的確認名單裡 |
傳統的 session 做法是:伺服器記住「這個 session id 對應到誰」,每次請求拿 id 去查。JWT 反過來——把身分寫在 token 裡,不記在伺服器,它有三段:標頭、內容(誰、何時簽的、何時過期)、簽章:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyZTdhMWI2Yy0uLi4iLCJleHAiOjE3...}.4f3c9a...
標頭 內容(可以直接解開來看) 簽章
中間那段不是加密的,任何人都解得開,它的安全性完全來自第三段:只有持有 secret 的人簽得出對的簽章,所以內容一旦被改,驗證就會失敗。
JWT 不是「看不到內容」,是「改不動內容」。 所以裡面不要放密碼、不要放不該被看到的東西。
不記狀態換來的是三個必須自己決定的問題:
| 問題 | 這個專案的決定 |
|---|---|
| 多久過期? | 12 小時。太短使用者一直被登出,太長被偷走的 token 有效期就長 |
| 要不要 refresh token? | 本次不做 |
| 怎麼撤銷? | 撤銷不了。 這是無狀態的代價——token 沒過期之前,伺服器沒有辦法讓它失效 |
RBAC(Role-Based Access Control)是最常見的權限模型:開一張角色表、一張權限表、一張關聯表,然後問「這個人有沒有這個角色」。
一講到權限,多數人的預設反應就是它。但我在這個專案 grep 了一次:
role / Role / permission / Permission → 0 處
一個角色都沒有。而權限檢查確實存在,如下:
if note.author_id != user.id: # 你是不是作者
raise errors.not_note_author()
if user.id not in required_confirmer_ids(db, version_id): # 你在不在名單裡
raise errors.not_required_confirmer()
這兩個都不是角色,是關係。
| 問的問題 | 例子 | |
|---|---|---|
| 角色 | 這個人是什麼 | 管理員可以停用帳號 |
| 關係 | 這個人跟這份資料是什麼 | 作者可以編輯自己的草稿 |
兩者的差別:角色跟資料無關(管理員對每一份記錄都是管理員),而關係跟資料有關(你是這份文件的作者,但不是另一份的作者)。
硬把關係套進 RBAC 會變成「每一份記錄都要動態產生一個角色」——那只是把關係換個名字,還多一層。
RBAC 划算的時機:有一群人共享同一組權限,而且那組權限跟特定資料無關。
這個系統目前沒有那種東西,所以不做。
AI 產規格的時候,遇到沒講清楚的地方會自己補,之前有說過它的判準是「業界有沒有慣例」。這在大部分功能上是好事,在安全功能上剛好相反——因為安全領域的慣例特別多,但慣例不等於你的系統需要。
叫它「做登入」,很可能連帶拿到 RBAC、refresh token、註冊流程、忘記密碼。每一項單獨看都合理,但每一項都是你沒有要求、之後要維護、而且可能永遠用不到的東西。
做法:把不做的東西連同理由寫進規格,跟要做的放在一起。
- 不做 RBAC。這個系統 role / permission 零命中,授權全部是關係式的。
- 不做註冊。功能清單裡沒有。
- refresh token 這輪不做。理由與當初押後登入一樣:標準做法、風險低。
而這個理由要寫下來,失效條件才跟著存在。
功能需求講「做得到什麼」,安全需求講「做不到什麼」。把這次的驗收條件排在一起看:
全部是否定句。 而否定句有一個要命的性質:
斷言「不應該發生 X」的測試,對著一個還沒實作的程式,一律通過。
做法:先斷言正常路徑真的有東西,再驗有沒有「不存在」。
setToken('a.b.c')
expect(currentToken()).toBe('a.b.c') // 守門:先證明它真的存得進去
clearToken()
expect(currentToken()).toBeNull() // 這條現在才有意義
這類東西 AI 不會主動提,因為從它的角度看一切正常。兩個實際的例子:
查無帳號也要花一樣的時間。
user = user_repository.get_by_email(db, email)
if user is None:
security.verify_password(password, _DUMMY_HASH) # ← 這行
raise errors.invalid_credentials()
密碼雜湊是刻意設計成慢的。少了那行,「查無帳號」會明顯比「密碼錯誤」快回來——回應文字一模一樣,但時間差就足以列舉帳號。
兩條路要長得一樣,不只是回應內容,連花的時間都要。
「過期」和「無效」要分開。
兩個都是 401,很容易併成一個錯誤碼。但前端的處理完全不同:過期可以靜默重新登入,偽造的不行——那代表有人在試。合成一個碼,前端就只能選一種處理方式,而兩種選擇都是錯的。
做法:這類問題沒有自動化的守法,只能靠固定的提問清單。我現在會問這三個:
明天:認證補上了,明天要處理這個系統最難的狀態:版本控管與簽核流。